package com.ihr360.rest.core;

import lombok.NonNull;
import lombok.Value;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;

/**
 * Wrapper class to register Ihr360Projection definitions for later lookup by name and source type.
 *
 * @author David Wei
 */
public class Ihr360ProjectionDefinitionConfiguration implements Ihr360ProjectionDefinitions {
    private static final String PROJECTION_ANNOTATION_NOT_FOUND = "Ihr360Projection annotation not found on %s! Either add the annotation or hand source type to the registration manually!";
    private static final String DEFAULT_PROJECTION_PARAMETER_NAME = "Ihr360Projection";

    private final Set<ProjectionDefinition> projectionDefinitions;
    private String parameterName = DEFAULT_PROJECTION_PARAMETER_NAME;

    /**
     * Creates a new {@link Ihr360ProjectionDefinitionConfiguration}.
     */
    public Ihr360ProjectionDefinitionConfiguration() {
        this.projectionDefinitions = new HashSet<ProjectionDefinition>();
    }

    @Override
    public String getParameterName() {
        return parameterName;
    }

    /**
     * Configures the request parameter name to be used to accept the Ihr360Projection name to be returned.
     *
     * @param parameterName defaults to {@value Ihr360ProjectionDefinitionConfiguration#DEFAULT_PROJECTION_PARAMETER_NAME}, will
     *          be set back to this default if {@literal null} or an empty value is configured.
     */
    public void setParameterName(String parameterName) {
        this.parameterName = StringUtils.hasText(parameterName) ? parameterName : DEFAULT_PROJECTION_PARAMETER_NAME;
    }

    /**
     * Adds the given Ihr360Projection type to the configuration. The type has to be annotated with {@link Ihr360Projection} for
     * additional metadata.
     *
     * @param projectionType must not be {@literal null}.
     * @return
     * @see Ihr360Projection
     */
    public Ihr360ProjectionDefinitionConfiguration addProjection(Class<?> projectionType) {

        Assert.notNull(projectionType, "Ihr360Projection type must not be null!");
        Ihr360Projection annotation = AnnotationUtils.findAnnotation(projectionType, Ihr360Projection.class);

        if (annotation == null) {
            throw new IllegalArgumentException(String.format(PROJECTION_ANNOTATION_NOT_FOUND, projectionType));
        }

        String name = annotation.name();
        Class<?>[] sourceTypes = annotation.types();

        return StringUtils.hasText(name) ? addProjection(projectionType, name, sourceTypes)
                : addProjection(projectionType, sourceTypes);
    }

    /**
     * Adds a Ihr360Projection type for the given source types. The name of the Ihr360Projection will be defaulted to the
     * uncapitalized simply class name.
     *
     * @param projectionType must not be {@literal null}.
     * @param sourceTypes must not be {@literal null} or empty.
     * @return
     */
    public Ihr360ProjectionDefinitionConfiguration addProjection(Class<?> projectionType, Class<?>... sourceTypes) {

        Assert.notNull(projectionType, "Ihr360Projection type must not be null!");
        return addProjection(projectionType, StringUtils.uncapitalize(projectionType.getSimpleName()), sourceTypes);
    }

    /**
     * Adds the given Ihr360Projection type for the given source types under the given name.
     *
     * @param projectionType must not be {@literal null}.
     * @param name must not be {@literal null} or empty.
     * @param sourceTypes must not be {@literal null} or empty.
     * @return
     */
    public Ihr360ProjectionDefinitionConfiguration addProjection(Class<?> projectionType, String name,
                                                           Class<?>... sourceTypes) {

        Assert.notNull(projectionType, "Ihr360Projection type must not be null!");
        Assert.hasText(name, "Name must not be null or empty!");
        Assert.notEmpty(sourceTypes, "Source types must not be null!");

        for (Class<?> sourceType : sourceTypes) {
            this.projectionDefinitions.add(ProjectionDefinition.of(sourceType, projectionType, name));
        }

        return this;
    }


    @Override
    public Class<?> getProjectionType(Class<?> sourceType, String name) {
        return getProjectionsFor(sourceType).get(name);
    }


    @Override
    public boolean hasProjectionFor(Class<?> sourceType) {

        for (ProjectionDefinition definition : projectionDefinitions) {
            if (definition.sourceType.isAssignableFrom(sourceType)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Returns all projections registered for the given source type.
     *
     * @param sourceType must not be {@literal null}.
     * @return
     */
    public Map<String, Class<?>> getProjectionsFor(Class<?> sourceType) {

        Assert.notNull(sourceType, "Source type must not be null!");

        Class<?> userType = ClassUtils.getUserClass(sourceType);
        Map<String, ProjectionDefinition> byName = new HashMap<String, ProjectionDefinition>();
        Map<String, Class<?>> result = new HashMap<String, Class<?>>();

        for (ProjectionDefinition entry : projectionDefinitions) {

            if (!entry.sourceType.isAssignableFrom(userType)) {
                continue;
            }

            ProjectionDefinition existing = byName.get(entry.name);

            if (existing == null || isSubTypeOf(entry.sourceType, existing.sourceType)) {
                byName.put(entry.name, entry);
                result.put(entry.name, entry.targetType);
            }
        }

        return result;
    }

    private static boolean isSubTypeOf(Class<?> left, Class<?> right) {
        return right.isAssignableFrom(left) && !left.equals(right);
    }

    /**
     * Value object to define lookup keys for projections.
     *
     * @author Oliver Gierke
     */
    @Value
    //@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
    static final class ProjectionDefinition {

        private final @NonNull
        Class<?> sourceType, targetType;
        private final @NonNull String name;

        private ProjectionDefinition(Class<?> sourceType, Class<?> targetType, String name) {
            this.sourceType = sourceType;
            this.targetType = targetType;
            this.name = name;
        }
        /**
         * Creates a new {@link ProjectionDefinitionKey} for the given source type and name;
         *
         * @param sourceType must not be {@literal null}.
         * @param targetType must not be {@literal null}.
         * @param name must not be {@literal null} or empty.
         */
        static ProjectionDefinition of(Class<?> sourceType, Class<?> targetType, String name) {

            Assert.hasText(name, "Name must not be null or empty!");

            return new ProjectionDefinition(sourceType, targetType, name);
        }
    }
}